[Swift] 文字列の長さの取得についての検証
はじめに
モバイルアプリサービス部の中安です。
「文字列の長さ」とは、Swiftに限らず様々な言語で当たり前のように使いますが、 そのいずれも簡単なように見えてややこしく、容易そうに見えて困難です。 それは常に「Unicode」や「マルチバイト」、はたまた「書記素」などの問題が孕むからです。
Swiftもまたバージョンが変わるごとに文字列の扱いが変遷してきました。 その度に文字列がコレクションになったりならなかったりという迷いもこういった事情が絡んでるからなんだろうなと思います。
ただし、この記事ではそういう学術的な深い話はしません。 もちろんある一定の理解は必要かと思いますが、実際のアプリ開発でさっと手に入れたいのは「人が文字を見て数える文字数」かなと思います。
ですので、今記事は何個かの文字列長取得の方法と、その正確性と速さの検証を掲載し、実際にアプリに実装するならどのような方法があるかという観点で書きたいと思います。
ちなみに、もし文字列について深い話が知りたい場合は下記のブログあたりが参考になるかもしれません。
検証
検証方法
今回は下記のような文字列ケースで検証してみます。
文字列 | 内容 | 期待値 |
---|---|---|
"" | 空文字 | 0 |
"日本語です" | マルチバイト文字列 | 5 |
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" | アスキー文字 | 26 |
"??" | 絵文字 | 1 |
"????" | 絵文字 | 1 |
"?" | 絵文字 | 1 |
"??" | スキントーンを使った絵文字 | 1 |
"❄︎" | いわゆる機種依存文字的な特殊な文字列 | 1 |
"改行が\nある" | 改行あり | 6 |
"タブが\tある" | タブ文字あり | 6 |
"空白が ある" | 途中に空白あり | 6 |
"(割愛)" | 500文字の日本語文字列 | 500 |
"(割愛)" | 5000文字の日本語文字列 | 5000 |
補足をすると
- 表の中の「期待値」は「人が文字を見て数える文字数」のことです。
- 文字列長で問題になりやすいのがUnicodeに後から追加されていった絵文字たちの扱いですので、絵文字を重点的に検証対象にしています。
- 5000文字は人が普通に読んで10分弱くらいかかる文章量だそうです。
characters.count
まずは String#characters.count
を試します。
この方法はSwift3で文字列長と検索すると一番良く見られる方法かと思います。
"hello".characters.count
この方法の検証結果は下表になります。
文字列 | 内容 | 期待値 | 計測値 | 結果 | ベンチ |
---|---|---|---|---|---|
"" | 空文字 | 0 | 0 | OK | 0.000018 |
"日本語です" | マルチバイト文字列 | 5 | 5 | OK | 0.000019 |
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" | アスキー文字 | 26 | 26 | OK | 0.000019 |
"??" | 絵文字 | 1 | 1 | OK | 0.000016 |
"????" | 絵文字 | 1 | 4 | NG | 0.000019 |
"?" | 絵文字 | 1 | 1 | OK | 0.000017 |
"??" | スキントーンを使った絵文字 | 1 | 2 | NG | 0.000018 |
"❄︎" | いわゆる機種依存文字的な特殊な文字列 | 1 | 1 | OK | 0.000019 |
"改行が\nある" | 改行あり | 6 | 6 | OK | 0.000020 |
"タブが\tある" | タブ文字あり | 6 | 6 | OK | 0.000017 |
"空白が ある" | 途中に空白あり | 6 | 6 | OK | 0.000018 |
"(割愛)" | 500文字の日本語文字列 | 500 | 500 | OK | 0.000020 |
"(割愛)" | 5000文字の日本語文字列 | 5000 | 5000 | OK | 0.000057 |
残念ながら、一部の絵文字でNGが出てしまいました。 つまりは、この方法は正確性でいうと不十分であるということですね。
NSString#length
次は、Objective-Cから存在するNSStringを使った旧来的な文字列長の取得方法を試します。
("hello" as NSString).length
この方法の検証結果は下表になります。
文字列 | 内容 | 期待値 | 計測値 | 結果 | ベンチ |
---|---|---|---|---|---|
"" | 空文字 | 0 | 0 | OK | 0.000019 |
"日本語です" | マルチバイト文字列 | 5 | 5 | OK | 0.000017 |
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" | アスキー文字 | 26 | 26 | OK | 0.000021 |
"??" | 絵文字 | 1 | 4 | NG | 0.000018 |
"????" | 絵文字 | 1 | 11 | NG | 0.000018 |
"?" | 絵文字 | 1 | 2 | NG | 0.000019 |
"??" | スキントーンを使った絵文字 | 1 | 4 | NG | 0.000019 |
"❄︎" | いわゆる機種依存文字的な特殊な文字列 | 1 | 2 | NG | 0.000015 |
"改行が\nある" | 改行あり | 6 | 6 | OK | 0.000015 |
"タブが\tある" | タブ文字あり | 6 | 6 | OK | 0.000014 |
"空白が ある" | 途中に空白あり | 6 | 6 | OK | 0.000014 |
"(割愛)" | 500文字の日本語文字列だ | 500 | 500 | OK | 0.000021 |
"(割愛)" | 5000文字の日本語文字列 | 5000 | 5000 | OK | 0.000020 |
結果としては絵文字や特殊文字が壊滅的にNGです。
ただし、500文字でも5000文字でも速度がまったく変わらずに characters.count
よりも若干パフォーマンスがよいです。
utf16.count、 utf8.count、 unicodeScalars.count
それぞれの文字ビューにエンコードしてカウントするという方法もあります。 参考 : Swift の文字列の長さ
ソース的にはこういう書き方ですね。
"hello".utf16.count "hello".utf8.count "hello".unicodeScalars.count
まずは utf16.count
の結果です。
文字列 | 内容 | 期待値 | 計測値 | 結果 | ベンチ |
---|---|---|---|---|---|
"" | 空文字 | 0 | 0 | OK | 0.000017 |
"日本語です" | マルチバイト文字列 | 5 | 5 | OK | 0.000018 |
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" | アスキー文字 | 26 | 26 | OK | 0.000016 |
"??" | 絵文字 | 1 | 4 | NG | 0.000018 |
"????" | 絵文字 | 1 | 11 | NG | 0.000019 |
"?" | 絵文字 | 1 | 2 | NG | 0.000013 |
"??" | スキントーンを使った絵文字 | 1 | 4 | NG | 0.000020 |
"❄︎" | いわゆる機種依存文字的な特殊な文字列 | 1 | 2 | NG | 0.000016 |
"改行が\nある" | 改行あり | 6 | 6 | OK | 0.000017 |
"タブが\tある" | タブ文字あり | 6 | 6 | OK | 0.000017 |
"空白が ある" | 途中に空白あり | 6 | 6 | OK | 0.000020 |
"" | 500文字の日本語文字列 | 500 | 500 | OK | 0.000017 |
"" | 5000文字の日本語文字列 | 5000 | 5000 | OK | 0.000021 |
NSString の length と同じ機構だそうなので、結果としてはそれと同じになるようです。
次は utf8.count
の結果です。
文字列 | 内容 | 期待値 | 計測値 | 結果 | ベンチ |
---|---|---|---|---|---|
"" | 空文字 | 0 | 0 | OK | 0.000017 |
"日本語です" | マルチバイト文字列 | 5 | 15 | NG | 0.000017 |
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" | アスキー文字 | 26 | 26 | OK | 0.000017 |
"??" | 絵文字 | 1 | 8 | NG | 0.000016 |
"????" | 絵文字 | 1 | 25 | NG | 0.000017 |
"?" | 絵文字 | 1 | 4 | NG | 0.000016 |
"??" | スキントーンを使った絵文字 | 1 | 8 | NG | 0.000016 |
"❄︎" | いわゆる機種依存文字的な特殊な文字列 | 1 | 6 | NG | 0.000014 |
"改行が\nある" | 改行あり | 6 | 16 | NG | 0.000016 |
"タブが\tある" | タブ文字あり | 6 | 16 | NG | 0.000018 |
"空白が ある" | 途中に空白あり | 6 | 16 | NG | 0.000016 |
"(割愛)" | 500文字の日本語文字列 | 500 | 1500 | NG | 0.000018 |
"(割愛)" | 5000文字の日本語文字列 | 5000 | 14600 | NG | 0.000037 |
こちらはアスキーコードのみが正確性を有します。つまりはそういう使用用途でないと文字列長取得には厳しいかと思います。 アスキーコードとはいえ、速度が抜群に早いというわけでもないようです。
unicodeScalars.count
の結果です。
文字列 | 内容 | 期待値 | 計測値 | 結果 | ベンチ |
---|---|---|---|---|---|
"" | 空文字 | 0 | 0 | OK | 0.000018 |
"日本語です" | マルチバイト文字列 | 5 | 5 | OK | 0.000016 |
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" | アスキー文字 | 26 | 26 | OK | 0.000019 |
"??" | 絵文字 | 1 | 2 | NG | 0.000017 |
"????" | 絵文字 | 1 | 7 | NG | 0.000016 |
"?" | 絵文字 | 1 | 1 | OK | 0.000017 |
"??" | スキントーンを使った絵文字 | 1 | 2 | NG | 0.000018 |
"❄︎" | いわゆる機種依存文字的な特殊な文字列 | 1 | 2 | NG | 0.000017 |
"改行が\nある" | 改行あり | 6 | 6 | OK | 0.000017 |
"タブが\tある" | タブ文字あり | 6 | 6 | OK | 0.000017 |
"空白が ある" | 途中に空白あり | 6 | 6 | OK | 0.000017 |
"(割愛)" | 500文字の日本語文字列 | 500 | 500 | OK | 0.000019 |
"(割愛)" | 5000文字の日本語文字列 | 5000 | 5000 | OK | 0.000032 |
Unicodeの一定範囲内で巻き取れる絵文字等はちゃんと数えられるものの、範囲外になるとNGになります。 ここでは猫の絵文字だけがOKになっています。
enumerateSubstringsを使う
enumerateSubstringsを使って文字列長を取得する方法もあるようです。 実装は String の extension を作ってやることになります。
extension String { var enumerateSubstringsCount: Int { var len = 0 enumerateSubstrings(in: startIndex ..< endIndex, options: .byComposedCharacterSequences) { str, _, _, _ in if str != nil { len += 1 } } return len } } "hello".enumerateSubstringsCount
この方法の検証結果は下表になります。
文字列 | 内容 | 期待値 | 計測値 | 結果 | ベンチ |
---|---|---|---|---|---|
"" | 空文字 | 0 | 0 | OK | 0.000031 |
"日本語です" | マルチバイト文字列 | 5 | 5 | OK | 0.000061 |
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" | アスキー文字 | 26 | 26 | OK | 0.000176 |
"??" | 絵文字 | 1 | 1 | OK | 0.000036 |
"????" | 絵文字 | 1 | 1 | OK | 0.000037 |
"?" | 絵文字 | 1 | 1 | OK | 0.000037 |
"??" | スキントーンを使った絵文字 | 1 | 1 | OK | 0.000044 |
"❄︎" | いわゆる機種依存文字的な特殊な文字列 | 1 | 1 | OK | 0.000037 |
"改行が\nある" | 改行あり | 6 | 6 | OK | 0.000067 |
"タブが\tある" | タブ文字あり | 6 | 6 | OK | 0.000063 |
"空白が ある" | 途中に空白あり | 6 | 6 | OK | 0.000060 |
"(割愛)" | 500文字の日本語文字列 | 500 | 500 | OK | 0.002447 |
"(割愛)" | 5000文字の日本語文字列 | 5000 | 5000 | OK | 0.031083 |
ご覧のようにオールOKです。正確に文字列長を取得することができます。 しかし、ベンチマークを見ていただくと分かるように、軒並み他の方法よりもコストがかかっています。
一文字ずつ走査していく方法なので、当然文字数が増えれば増えるほどパフォーマンスは落ちていきます。
5000文字に至っては characters.count
の 0.000057 との比較では 545倍 時間がかかりました。
文字列長取得の実装
ここまでの検証を踏まえて、文字列の長さを取得するためには2通りのパターンを考えたほうが良さそうです。
- 完全に正確ではないが低コストで取得できるパターン
- 高コストだが正確に取得できるパターン
そのため、下記のように String の extension を実装してみます。
extension String { /// 文字列長を返す /// /// 絵文字や一部の特殊文字は正確に取得できない var length: Int { return characters.count } /// 文字列長を返す /// /// 絵文字や一部の特殊文字も考慮した厳密な長さを取得できるが、文字列の長さに比例してコストがかかる var strictLength: Int { var len = 0 enumerateSubstrings(in: startIndex ..< endIndex, options: .byComposedCharacterSequences) { str, _, _, _ in if str != nil { len += 1 } } return len } }
たとえばユーザによるUITextViewへの入力、またはWEB記事をそのまま取得する時などは strictLength
プロパティを使用し、
それ以外の特殊な文字列が入ることがない文字列に対しては length
プロパティを使用することで、正確性と速さを使い分けることができます。
Swift4では
最後にSwift4では文字列長はどのように取得するでしょうか。
Swift4で文字列は再びコレクションとして扱われることになります。したがってコレクションの要素数を返す count
プロパティが付加されます。
"hello".count // Swift3ではエラーになりますよ
同じように検証してみると
文字列 | 内容 | 期待値 | 計測値 | 結果 | ベンチ |
---|---|---|---|---|---|
"" | 空文字 | 0 | 0 | OK | 0.000017 |
"日本語です" | マルチバイト文字列 | 5 | 5 | OK | 0.000017 |
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" | アスキー文字 | 26 | 26 | OK | 0.000017 |
"??" | 絵文字 | 1 | 1 | OK | 0.000015 |
"????" | 絵文字 | 1 | 1 | OK | 0.000018 |
"?" | 絵文字 | 1 | 1 | OK | 0.000018 |
"??" | スキントーンを使った絵文字 | 1 | 1 | OK | 0.000017 |
"❄︎" | いわゆる機種依存文字的な特殊な文字列 | 1 | 1 | OK | 0.000016 |
"改行が\nある" | 改行あり | 6 | 6 | OK | 0.000017 |
"タブが\tある" | タブ文字あり | 6 | 6 | OK | 0.000015 |
"空白が ある" | 途中に空白あり | 6 | 6 | OK | 0.000016 |
"(割愛)" | 500文字の日本語文字列 | 500 | 500 | OK | 0.000021 |
"(割愛)" | 5000文字の日本語文字列 | 5000 | 5000 | OK | 0.000056 |
このようにオールOKな上に、パフォーマンスも characters.count
と差がありません。
つまりは悩ましい部分はSwift4で改善されたといえます。
前項で書いた extension の実装も不要の産物にすることができますね。
以上です。